昨天我們更新家用品所使用的 Item 模型,今天就可以來繼續整合家用品與分類和地點,並優化頁面。讓使用者在 App 中新增或編輯家用品時,可以方便地設定物品的分類和存放地點。
今天的目標是:
既然昨天更改了 Item 模型,就代表負責更新資料庫的 DataManager 也需要更新。我們需要將 addItem 與 updateItem 都更新,讓它們也需要儲存分類與地點資料。
// Create
func addItem(name: String, quantity: Int, price: Double, dateAdded: Date, expiryDate: Date?, category: ItemCategory, location: Location) -> Bool {
let newItem = Item(context: container.viewContext)
newItem.id = UUID()
newItem.name = name
newItem.quantity = Int16(quantity)
newItem.isUsedUp = false
newItem.dateAdded = dateAdded
newItem.price = price
newItem.usedQuantity = 0
newItem.expiryDate = expiryDate
newItem.category = category
newItem.location = location
return saveContext()
}
// Update
func updateItem(item: Item, name: String, quantity: Int, price: Double, dateAdded: Date, expiryDate: Date?, isUsedUp: Bool, usedQuantity: Int, category: ItemCategory, location: Location) -> Bool {
item.name = name
item.quantity = Int16(quantity)
item.isUsedUp = isUsedUp
item.dateAdded = dateAdded
item.price = price
item.usedQuantity = Int16(usedQuantity)
item.category = category
item.location = location
return saveContext()
}
不管是新增物品頁還是編輯物品頁,它們的 ViewModel 更改方法都大同小異,這邊就使用新增物品頁來示範。我們需要先建立以下變數:
class AddItemViewModel: ObservableObject {
@Published var categories: [ItemCategory] = []
@Published var locations: [Location] = []
@Published var category: ItemCategory?
@Published var location: Location?
接著我們需要在 init 時呼叫 func,讓 categories 和 locations 儲存資料庫抓出來的資料。
init(dataManager: DataManager = DataManager()) {
self.dataManager = dataManager
fetchItemCategory()
fetchLocation()
}
func fetchItemCategory() {
categories = dataManager.fetchItemCategories()
}
func fetchLocation() {
locations = dataManager.fetchLocations()
}
最後再更新 save(), 讓分類和地點也被傳遞到 dataManager.addItem 中,儲存到資料庫。
func save() {
guard let category, let location else {
failHandle = (isFail: true, title: "請選擇分類或地點")
return
}
if validateAndSave(), let priceValue = Double(price) {
// 資料驗證通過,儲存資料
let result = dataManager.addItem(
name: name,
quantity: quantity,
price: priceValue,
dateAdded: dateAdded,
expiryDate: shouldRemindExpiryDate ? expiryDate : nil,
category: category,
location: location
)
if result {
showSuccessToast = true
} else {
failHandle = (isFail: true, title: "資料新增失敗")
}
}
}
這樣 ViewModel 就更新完畢了~
一樣我們已新增頁面來示範。
新增一個新的 Section
,並使用 Picker
,讓使用者選取所要的分類和地點。
Section(header: Text("分類與地點")) {
Picker("選擇分類", selection: $viewModel.category) {
ForEach(viewModel.categories, id: \.id) { category in
Text(category.name).tag(category as ItemCategory?)
}
}
Picker("選擇地點", selection: $viewModel.location) {
ForEach(viewModel.locations, id: \.id) { location in
Text(location.name).tag(location as Location?)
}
}
}
Picker
是 SwiftUI 中的一個用來呈現多選項的元件,它可以呈現文字、圖文或 Segmented Control 讓使用者選擇一個選項。它類似於傳統 UI 中的下拉選單,常用於需要選擇單一選項的場景。
基本結構範例如下:
Picker("選擇分類", selection: $選擇的變數) {
Text("選項1").tag(選項值)
Text("選項2").tag(選項值)
}
在 SwiftUI 中,Picker
的選擇是通過綁定的變數(例如 @State
或 @Binding
)來進行。selection
參數指向一個綁定的變數,當用戶選擇一個選項時,這個變數會自動更新為對應的選項值。
為什麼要加上 .tag()
在 Picker
中,每一個選項的標籤都需要通過 .tag()
明確標識它所對應的值。這是因為 Picker
需要根據這些標籤來識別選項,並將所選的項目與 selection
綁定的變數進行比較與更新。
如果沒有加上 .tag()
,SwiftUI 不知道 Picker
中每個選項對應的值是什麼,也無法將選項與 selection 的變數進行匹配。這會導致當使用者選擇一個項目時,無法正確地更新綁定的變數。
在這個例子中,.tag(category as ItemCategory?)
告訴 Picker
,當用戶選擇該選項時,viewModel.category 將會更新為 category,從而正確顯示用戶的選擇。
參考資料:
接下來,我們要優化首頁的列表,讓使用者可以一眼看到物品的分類與存放地點。
將物品清單的顯示功能抽取成新的 ItemListView 和 ItemRow,讓程式碼更具可讀性。
原 HomeView(ContentView) 程式碼
List {
ForEach(viewModel.items) { item in
NavigationLink(destination: EditItemView(viewModel: EditItemViewModel(dataManager: viewModel.dataManager, item: item))) {
HStack {
VStack(alignment: .leading) {
Text(item.name)
if let expiryDate = item.expiryDate {
Text("到期日\(expiryDate)")
.font(.subheadline).foregroundColor(.gray)
}
}
Spacer()
Text("數量: \(item.quantity)").font(.subheadline)
}
}
}
.onDelete(perform: viewModel.deleteItems)
}
.onAppear {
viewModel.fetchItems()
}
這段程式碼的問題在於,List
和物品的顯示混雜在一起,這使得每次物品顯示的樣式改動都必須進行大範圍的修改。因此,我們將物品顯示的邏輯獨立出來,分為 ItemListView 和 ItemRow。
ItemListView 負責處理物品清單的顯示,並在物品數量為 0 的情況下,顯示提示文字與圖案,告知使用者清單中尚無任何紀錄。
struct ItemListView: View {
@ObservedObject var viewModel: ItemViewModel
var body: some View {
if (viewModel.items.count > 0) {
List {
ForEach(viewModel.items, id: \.id) { item in
NavigationLink(destination: EditItemView(viewModel: EditItemViewModel(dataManager: viewModel.dataManager, item: item))) {
ItemRow(item: item)
}
}
.onDelete(perform: viewModel.deleteItems)
}
} else {
VStack {
Image(systemName: "list.clipboard")
.resizable()
.frame(width: 100, height: 140)
.padding()
Text("沒有紀錄")
Text("點擊+號新增一筆")
}
.foregroundColor(.gray)
}
}
private var itemFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.locale = Locale.current
return formatter
}
}
#Preview {
let dataManager = DataManager()
let viewModel = ItemViewModel()
return ItemListView(viewModel: ItemViewModel())
}
ItemRow 是用來顯示單筆物品資料的元件,包含物品名稱、分類圖示、到期日,以及存放位置。
import SwiftUI
struct ItemRow: View {
@ObservedObject var item: Item
var body: some View {
HStack {
Image(systemName: item.category.iconName)
.resizable()
.frame(width: 40, height: 40)
.foregroundColor(Color(hexString: item.category.categoryGroup.colorHex))
.padding([.trailing], 20)
VStack(alignment: .leading) {
Text(item.name)
.font(.headline)
if let expiryDate = item.expiryDate {
Text(verbatim: "到期日:\(String(describing: expiryDate.formatted(date: .abbreviated, time: .omitted)))")
.font(.subheadline)
.foregroundColor(.gray)
} else {
Text(verbatim: "到期日:未設定")
.font(.subheadline)
.foregroundColor(.gray)
}
}
Spacer()
Text(item.location.name)
.font(.system(size: 14, weight: .bold))
.padding()
.background(Color(hexString: item.location.colorHex) ?? .black)
.foregroundColor(.white)
.cornerRadius(10)
}
.padding()
}
}
將 HomeView 中的 List
改為引用 ItemListView,並設定好其佈局限制,讓它能夠符合父容器的大小:
ItemListView(viewModel: viewModel)
.frame(maxHeight: .infinity)
.frame(maxWidth: .infinity)
.onAppear {
viewModel.fetchItems()
}
今天的實作將分類、地點管理與物品管理完美整合,並優化使用者體驗。在首頁顯示物品時,使用者可以直接看到物品的分類與存放地點,提升管理效率。同時,我們透過元件化設計,讓程式碼更加簡潔與易讀。明天我們將實作帳務報表功能,明天見!